接續著上一篇的前端實作分享,我們介紹完 NavBar 的核心功能邏輯以及響應式設計,這篇文章中我們的焦點將轉向「登入/註冊頁面」,深入挖掘使用者認證的細節與技巧。接著逐一解析「基本設定」與「連結設定」的實作流程,包括資料取得後的格式轉換以及請求的處理方法。最後是「前台頁面」的延伸功能。實作中所有的表單處理為先前介紹的 React-Form-Hook 以及 Zod 做驗證,可參考系列文的介紹。讓我們繼續這趟前端實作之旅吧!
首先以「登入/註冊頁面」做為開端,回顧一下系列文中,我們利用 NextAuth.js 實現身分驗證,並介紹了 API 設置和 OAuth 的步驟。這邊我們將探討如何發送請求以及接收和呈現請求結果給使用者。實作方法會分為一般的帳號密碼以及 OAuth 註冊登入。
在專案中有提供以使用者自行以帳號密碼的方式進行註冊登入,在 NextAuth.js
中,我們使用了 Credentials Provider 來設定這個登入機制。但當涉及到「註冊」時,需要在後端實作部分建立一個 register 的 api 讓用戶的資料直接寫入資料庫中。功能主要以 React-Form-Hook 進行資料的收集與驗證後,在 onSubmit
的處理函式中加入以 axios
發送請求的相關邏輯。
/api/register
router.push
實現const onSubmit: SubmitHandler<FieldValues> = async (data) => {
try {
await axios.post('/api/register', data);
router.push('/portal/basic');
} catch (err) {
toast.error('註冊失敗');
}
}
當使用者試圖登入時,我們會使用 NextAuth.js
提供的 signIn
方法並帶入參數,等於進行 POST "api/auth/signin/credentials"
的請求,當請求發送時,它會觸發 Credentials Provider 的登入機制。
redirect
設置為 false
,若登入失敗,頁面不會重新導向。callbackUrl
是登入成功後導向的 URL。如果用戶在登入前試圖訪問受保護的頁面,登入成功後,他們會被重定向到之前嘗試訪問的那個頁面。但是,如果用戶是直接點擊登入按鈕來進行登入,他們將被導向到我們在
signIn
方法中設定的callbackUrl
。若未設定callbackUrl
,系統將預設將用戶導回主頁 “/” 路徑。
以下是登入的實作範例:
signIn
發送請求登入,等待請求結果callback?.ok
回應為 true,表示登入成功,依據 callbackUrl 的設置,將導向指定的頁面並跳出成功的通知。callback?.error
存在,表示登入失敗。由於設定了 redirect: false
,所以用戶不會被重新導向到其他頁面,而是會在當前頁面收到一個表示登入失敗的通知。const onSubmit: SubmitHandler<FieldValues> = (values) => {
signIn('credentials', {
email: values.email,
password: values.password,
redirect: false,
callbackUrl: CALLBACK_URL
})
.then((callback) => {
callback?.error
? toast.error('登入失敗')
: toast.success('登入成功')
})
}
第三方驗證的方式相對簡潔非常多,因為登入及註冊都是調用 signIn
的方法請求,輸入參數及後續邏輯也都相同,由於各個 OAuth 只有輸入參數不同,因此也封裝為 handleSocialLogIn 的 function:
signIn
請求登入,並於第一個參數輸入使用的 OAuth 類型 (參數型別為 String)
POST "api/auth/signin/<OAuth 類型>"
const handleSocialLogIn = async (socialType: string) => {
try {
await signIn(socialType, {
callbackUrl: CALLBACK_URL
})
toast.success('登入成功')
} catch (error) {
toast.error('登入失敗')
}
}
// 調用 handler 並傳入驗證類型
<Button onClick={() => handleSocialLogIn("google")} />;
在基本設定頁面中,我們將著重於三大功能:首先是對圖片的上傳與資料收集;其次是能夠重置已上傳的圖片;最後,應用程式提供了一個預覽功能,讓用戶可以即時查看所做的更改。
通過組合 Next-Cloudinary 的元件和 React-Form-Hook,我們實現了圖片的上傳和重置。Cloudinary 元件上傳圖片後,會回傳一個 CDN 的 image URL,這個 URL 會暫時存放在 React-Form-Hook 的 useForm
狀態中:
const handleUpload = (url: string) => {
// setCustomValue 為以 setValues 封裝的 function
setCustomValue("customImage", url);
};
而操作介面中圖片會顯示出來,此圖片的狀態由 useForm
控制,初載入以全域的 user state 的資料呈現,上傳後會以新的值顯示:
當使用者上傳圖片後,新的網址只會暫時儲存在 useForm
的狀態中,並不會發送任何請求或修改 Zustand 的全域狀態。因此,如果使用者後悔並希望回到原先的照片,可以直接使用 useForm
中的 resetField
方法來重置圖片欄位回到其預設值:
const handleResetImg = () => {
resetField("customImage");
};
在應用程式中的所有預覽功能資料來源都是取自全域狀態,而預覽時也希望在更新狀態前做一遍資料驗證,所以會使用到 useForm 的 trigger
功能:
trigger
進行資料驗證。如果回傳值是 false
,表示驗證未通過,不再進行後續操作getValues
取得 useForm
中的表單資料const handlePreview = async () => {
const result = await trigger();
if (!result) return;
const basicValues = getValues();
update({ user: basicValues });
};
連結設定頁面主要著重於使用者的連結管理,如新增或修改。同時,我們也提供更新連結顯示順序的功能,並允許使用者在調整後選擇發送更新請求或取消排序。
兩個操作的邏輯都是將 useForm
收集的資料進行 API 請求,請求後將回傳的資料更新於全域的 links state,不同的是 endpoint,以及參數的傳遞
新增:以 POST 請求,並調用 addLink
更新狀態
const { data: res } = await axios.post<AxiosResponse<Link>>(
`/api/user/${user?.id}/links`,
result
);
addLink(res.data);
修改:以 PUT 請求,傳遞需修改連結的 id 以及修改的內容,並調用 updateLink
更新狀態
const { data: res } = await axios.put<AxiosResponse<Link>>(
`/api/user/${user?.id}/links/${values.id}`,
result
);
updateLink(res.data.id, res.data);
連結設定頁面加入了編輯順序模式,讓使用者透過拖放方式調整連結的順序。當進入此模式時,有幾個重要的實作細節:
結合 React-Beautiful-Dnd 的 onDragEnd
功能,必須同時更新 user state,實現拖放後的即時預覽。
const onDragEnd = (result: DropResult) => {
// 經過拖曳後的重新排序
const items = reorder(links, result.source.index, result.destination.index);
// 將排序後的結果更新於全域狀態達到即時預覽效果
update({ links: items });
};
如果 React-Beautiful-Dnd 的元件中使用 order 作為拖放時的 index ,可能會造成操作的判斷錯誤,但 API 的回傳又要依據 order 判斷,所以在 API 請求前,需要格式化連結的 order 值。
const updateLinks = links?.map((item, index) => {
return {
...item,
order: links.length - index, // 以降冪排序
};
});
const { data: res } = await axios.patch(`/api/user/${user?.id}/links`, {
links: updateLinks,
});
若使用者決定取消調整,revertLinks
功能能夠快速恢復到原始的全域狀態。
// 全域狀態後先以 useState 保存原始狀態
const links = useSetup((state) => state.links)
const [originalOrder] = useState<LinkSetupType[] | null>(links)
// 以原始的狀態恢復 links state
const handleCancelUpdate = () => {
revertLinks(originalOrder);
};
連結的排序可以分為資料庫以及全域狀態儲存的
links.length - index
實現降冪排序。前台頁面不僅呈現後台設定的資訊,為了提高使用者的操作體驗,我們也加入了一些附加功能。其中,我們設計了一個動態的『更多』按鈕。點擊後,它會以動畫效果展開一個小視窗,讓使用者可以方便地複製連結。
實現複製的功能使用的是 Clipboard API,可以向瀏覽器中的系統剪貼簿進行訪問操作,是以非同步的方式讀取或寫入剪貼簿,不僅可以處理純文字也可以處理 HTML 內容:
writeText
操作複製行為const copyUrl = `<my-domain>.com/${username}`;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(linkUrl);
toast.success("已複製連結");
} catch (err) {
toast.error("連結複製失敗");
}
};
連續兩天的前端實作在此告一段落,比起冗長的逐行程式碼,希望是以一個重點操作來做介紹,在寫這兩篇文章的同時也發現當初開寫的時候有很多層面沒考慮到,等於也再重新 code review 了一次。實作結束但只是功能都正常可以運作,不過當我們遇到錯誤時都會跳出驚悚的預設畫面,所以下章節我們將進入前端錯誤處理的部分,讓使用者獲得更友善的用戶體驗,同時也提供開發者有效的錯誤資訊。
相關程式碼同步收錄於:https://github.com/ysl0628/2023-ithelp/blob/main/day-24/README.md
https://ithelp.ithome.com.tw/articles/10271977